<?php
/**
 * @noinspection PhpUnhandledExceptionInspection
 * @noinspection PhpDocMissingThrowsInspection
 */
declare(strict_types=1);

namespace MarcinOrlowski\ResponseBuilder\Tests\ExceptionHandlerHelper;

/**
 * Laravel API Response Builder
 *
 * @author    Marcin Orlowski <mail (#) marcinOrlowski (.) com>
 * @copyright 2016-2025 Marcin Orlowski
 * @license   http://www.opensource.org/licenses/mit-license.php MIT
 * @link      https://github.com/MarcinOrlowski/laravel-api-response-builder
 */

use Illuminate\Auth\AuthenticationException;
use Illuminate\Support\Facades\Config;
use Illuminate\Validation\ValidationException;
use MarcinOrlowski\Lockpick\Lockpick;
use MarcinOrlowski\PhpunitExtraAsserts\ExtraAsserts;
use MarcinOrlowski\PhpunitExtraAsserts\Generator;
use MarcinOrlowski\ResponseBuilder\ApiResponse;
use MarcinOrlowski\ResponseBuilder\BaseApiCodes;
use MarcinOrlowski\ResponseBuilder\ExceptionHandlerHelper;
use MarcinOrlowski\ResponseBuilder\ExceptionHandlers\DefaultExceptionHandler;
use MarcinOrlowski\ResponseBuilder\ResponseBuilder as RB;
use MarcinOrlowski\ResponseBuilder\Tests\TestCase;
use Symfony\Component\HttpFoundation\Response as HttpResponse;
use Symfony\Component\HttpKernel\Exception\HttpException;

/**
 * Class ExceptionHandlerHelperTest
 */
class ExceptionHandlerHelperTest extends TestCase
{
    /**
     * Tests behaviour of ExceptionHandler::unauthenticated()
     */
    public function testUnauthenticated(): void
    {
        $exception = new AuthenticationException();

        $obj = new ExceptionHandlerHelper();
        /** @var HttpResponse $eh_response */
        $eh_response = Lockpick::call($obj, 'unauthenticated', [null,
                                                                $exception,
        ]);

        $response = ApiResponse::fromJson($this->getResponseContent($eh_response));
        $this->assertNull($response->getData());
        $this->assertEquals(BaseApiCodes::EX_AUTHENTICATION_EXCEPTION(), $response->getCode());
        $this->assertEquals($exception->getMessage(), $response->getMessage());
    }

    /**
     * Tests if optional debug info is properly added to JSON response
     */
    public function testErrorMethodWithDebugTrace(): void
    {
        /** @noinspection PhpUndefinedClassInspection */
        \Config::set(RB::CONF_KEY_DEBUG_EX_TRACE_ENABLED, true);

        $exception = new \RuntimeException();

        $dummy_request = new \Illuminate\Http\Request();
        $content = ExceptionHandlerHelper::render($dummy_request, $exception);
        $api = ApiResponse::fromJson($this->getResponseContent($content));
        $this->assertNull($api->getData());
        $this->assertNotNull($api->getDebug());

        // Note that we do not check what debug node contains. It's on purpose as whatever ends up
        // there is not generated by us, so may change at any time.
    }

    // ---------------------------------------------------------------------------------------------

    /**
     * Check exception handler behavior when provided with various exception types.
     */
    public function testRenderMethodWithHttpException(): void
    {
        $codes = [
            [
                'exception_class'       => ValidationException::class,
                'expected_http_code'    => HttpResponse::HTTP_UNPROCESSABLE_ENTITY,
                'expected_api_code'     => BaseApiCodes::EX_VALIDATION_EXCEPTION(),
                'do_message_validation' => false,
                'has_data'              => true,
            ],
        ];

        foreach ($codes as $exception_type => $params) {
            $this->doTestSingleException((string)($exception_type), $params['exception_class'],
                $params['expected_http_code'], $params['expected_api_code'],
                $params['do_message_validation'], $params['has_data']);
        }
    }

    /**
     * Handles single exception testing.
     *
     * @param string $exception_config_key           ResponseBuilder's config key for this particular
     *                                               exception.
     * @param string $exception_class                Name of the class of exception to be constructed.
     * @param int    $expected_http_code             Expected response HTTP code
     * @param int    $expected_api_code              Expected response API code
     * @param bool   $validate_response_message_text Set to @true, to validate returned response message with
     *                                               current localization.
     * @param bool   $expect_data                    Set to @true if response is expected to have non null
     *                                               `data` node.
     *
     * @noinspection PhpTooManyParametersInspection
     */
    protected function doTestSingleException(string $exception_config_key,
                                             string $exception_class,
                                             int    $expected_http_code, int $expected_api_code,
                                             bool   $validate_response_message_text = true,
                                             bool   $expect_data = false): void
    {
        $key = BaseApiCodes::getCodeMessageKey($expected_api_code);
        $expect_data_node_null = true;
        switch ($exception_class) {
            case HttpException::class:
                $exception = new $exception_class($expected_http_code);
                break;

            case ValidationException::class:
                $data = ['title' => ''];
                $rules = ['title' => 'required|min:10|max:255'];
                $validator = app('validator')->make($data, $rules);
                $exception = new ValidationException($validator);
                $expect_data_node_null = false;
                break;

            default:
                $exception = new $exception_class(null, $expected_http_code);
                break;
        }

        // hand the exception to the handler and examine its response JSON
        $dummy_request = new \Illuminate\Http\Request();
        /** @var \Throwable $exception */
        $eh_response = ExceptionHandlerHelper::render($dummy_request, $exception);
        $api = ApiResponse::fromJson($this->getResponseContent($eh_response));
        if ($expect_data_node_null) {
            $this->assertNull($api->getData());
        }

        $ex_message = trim($exception->getMessage());
        if ($ex_message === '') {
            $ex_message = '???';
        }

        /** @noinspection PhpUndefinedClassInspection */
        /** @var string $key */
        $error_message = $this->langGet($key, [
            'response_api_code' => $expected_api_code,
            'message'           => $ex_message,
            'class'             => \get_class($exception),
        ]);

        if ($validate_response_message_text) {
            $this->assertEquals($error_message, $api->getMessage());
        }
        $this->assertEquals($expected_http_code, $eh_response->getStatusCode(),
            sprintf('Unexpected HTTP code value for "%s".', $exception_config_key));
        if ($expect_data) {
            $data = $api->getData();
            $this->assertNotNull($data);
            /** @var array<string, mixed> $data */
            $this->assertArrayHasKey(RB::KEY_MESSAGES, $data);
            $this->assertIsArray($data[ RB::KEY_MESSAGES ]);
        }
    }

    // ---------------------------------------------------------------------------------------------

    /**
     * Tests if ExceptionHandler's error() method will correctly drop invalid HTTP
     * found in configuration, and try to obtain code from the exception.
     */
    public function testHttpCodeFallbackToExceptionStatusCode(): void
    {
        // GIVEN invalid configuration with exception handler's http_code set
        // to value below min. allowed 400
        $config_http_code = HttpResponse::HTTP_OK;

        // AND having HttpException with valid http_code
        $expected_http_code = HttpResponse::HTTP_BAD_REQUEST;
        $ex = new HttpException($expected_http_code);

        // THEN we should get valid response with $expected_http_code used.
        $this->doTestErrorMethodFallbackMechanism($expected_http_code, $ex, $config_http_code);
    }

    /**
     * Checks if error() will fall back to provided HTTP code, given the fact exception
     * handler configuration uses invalid `http_code` but also Exception's http status
     * code is set to invalid value. In such case we should fallback to DEFAULT_HTTP_CODE_ERROR.
     */
    public function testHttpCodeFallbackToProvidedFallbackValue(): void
    {
        // http codes below 400 are invalid
        $config_http_code = HttpResponse::HTTP_OK;
        $expected_http_code = RB::DEFAULT_HTTP_CODE_ERROR;

        $ex = new HttpException(HttpResponse::HTTP_OK);
        $this->doTestErrorMethodFallbackMechanism($expected_http_code, $ex, $config_http_code);
    }

    /**
     * Checks if Exception Handler would successfuly provide error message for valid HttpExceptions that
     * do not have dedicated error message configured.
     */
    public function testDefaultExceptionMessages(): void
    {
        // get the translation array for default language
        $translation = $this->getTranslationForDefaultLang();

        $code = 401;
        $key = "http_{$code}";
        // there are some gaps in the codes defined, but as default language  covers all codes supported,
        // then we can safely skip the codes not covered by default language.
        if (\array_key_exists($key, $translation)) {
            $ex = new HttpException($code);
            /** @var HttpResponse $response */
            $response = Lockpick::call(ExceptionHandlerHelper::class, 'render', [
                    null,
                    $ex,
                ]
            );

            $api = ApiResponse::fromJson($this->getResponseContent($response));
            $this->assertEquals($translation[ $key ], $api->getMessage(),
                "error message mismatch for http code: {$code}");
        }
    }

    /**
     * Tests if Exception Handler's default (built-in) configuration matches structure requrements.
     */
    public function testBaseConfigStructure(): void
    {
        $cfg = $this->getExceptionHandlerConfig();
        $keys = [
            HttpException::class,
            RB::KEY_DEFAULT,
        ];
        ExtraAsserts::assertArrayHasKeys($keys, $cfg);

        // check http_exception block and validate all required entries and the config content.
        /** @var array<string, mixed> $http_exception_config */
        $http_exception_config = $cfg[ HttpException::class ];
        /** @var array<string, mixed> $http_cfg */
        $http_cfg = $http_exception_config[ RB::KEY_CONFIG ];
        $this->assertGreaterThanOrEqual(1, \count($http_cfg));
        $keys = [HttpResponse::HTTP_UNAUTHORIZED,];

        foreach ($keys as $key) {
            if (array_key_exists($key, $http_cfg)) {
                $this->assertArrayHasKey($key, $http_cfg);
                /** @var array<string, mixed> $key_config */
                $key_config = $http_cfg[ $key ];
                $this->checkExHandlerConfigStructure($key_config, null, false);
            }
        }
        $this->assertArrayHasKey(RB::KEY_DEFAULT, $http_cfg);
        /** @var array<string, mixed> $default_config */
        $default_config = $http_cfg[ RB::KEY_DEFAULT ];
        $this->checkExHandlerConfigStructure($default_config);

        // check default handler config
        /** @var array<string, mixed> $default_handler */
        $default_handler = $cfg[ RB::KEY_DEFAULT ];
        /** @var array<string, mixed> $default_handler_config */
        $default_handler_config = $default_handler[ RB::KEY_CONFIG ];
        $this->checkExHandlerConfigStructure($default_handler_config);
    }

    /**
     * Validates ExceptionHandler's built-in configuration related to HttpException class.
     */
    public function testBaseConfigHttpExceptionConfig(): void
    {
        $http_cfg = $this->getExceptionHandlerConfig();
        /** @var array<string, mixed> $http_exception_config */
        $http_exception_config = $http_cfg[ HttpException::class ];
        /** @var array<string, mixed> $cfg */
        $cfg = $http_exception_config[ RB::KEY_CONFIG ];

        foreach ($cfg as $code => $params) {
            if (\is_int($code)) {
                /** @var array<string, mixed> $params */
                $this->checkExHandlerConfigStructure($params, $code);
            } elseif (\is_string($code) && $code == 'default') {
                /** @var array<string, mixed> $params */
                $this->checkExHandlerConfigStructure($params, null, true);
            } else {
                $this->fail("Code '{$code}' is not allowed in config->exception_handler->http_exception.");
            }
        }
    }

    /**
     * Checks if ExceptionHandler would return exception's message if exists but fall
     * back to `msg_key` ignoring built-in default string
     */
    public function testExceptionMessageOverrideExceptionMessageOnly(): void
    {
        // HAVING exception handler configured to use user provided message string
        $api_code = BaseApiCodes::EX_HTTP_NOT_FOUND();
        $http_code = HttpResponse::HTTP_SERVICE_UNAVAILABLE;
        $msg_key = Generator::getRandomString('key');
        $cfg = [
            RB::KEY_DEFAULT => [
                RB::KEY_HANDLER => DefaultExceptionHandler::class,
                RB::KEY_CONFIG  => [
                    RB::KEY_API_CODE  => $api_code,
                    RB::KEY_HTTP_CODE => $http_code,
                    RB::KEY_MSG_KEY   => $msg_key,
                    RB::KEY_MSG_FORCE => false,
                ],
            ],
        ];
        Config::set(RB::CONF_KEY_EXCEPTION_HANDLER, $cfg);

        // GIVEN exception with message that should be handled
        $ex_msg = Generator::getRandomString('user_msg');
        $ex = new \RuntimeException($ex_msg);

        /** @var HttpResponse $http_response */
        $http_response = Lockpick::call(
            ExceptionHandlerHelper::class, 'render', [null, $ex,]);
        $api = ApiResponse::fromJson($this->getResponseContent($http_response));

        // THEN we should see exception message.

        // however thre's no message matching $msg_key, but Lang::get() would return
        // the key if no string exists, which is sufficient
        $this->assertEquals($ex_msg, $api->getMessage());

        $this->assertEquals($http_code, $http_response->getStatusCode());
        $this->assertEquals($api_code, $api->getCode());
    }

    /**
     * Checks if ExceptionHandler would ignore exception's message as well as built-in fallback message
     * and use the one configured with `msg_key` instead.
     */
    public function testExceptionMessageForceOverride(): void
    {
        // HAVING exception handler configured to use user provided message string
        $api_code = BaseApiCodes::EX_HTTP_NOT_FOUND();
        $http_code = HttpResponse::HTTP_SERVICE_UNAVAILABLE;
        $msg_key = Generator::getRandomString('key');
        $cfg = [
            'map' => [
                'default' => [
                    'api_code'  => $api_code,
                    'http_code' => $http_code,
                    'msg_key'   => $msg_key,
                    'msg_force' => true,
                ],
            ],
        ];
        Config::set(RB::CONF_KEY_EXCEPTION_HANDLER, $cfg);

        // GIVEN exception that should be handled
        $ex = new \RuntimeException('this message should be ignored');

        /** @var HttpResponse $http_response */
        $http_response = Lockpick::call(ExceptionHandlerHelper::class, 'error', [
                $ex,
                $api_code,
                $http_code,
                $msg_key,
            ]
        );

        $api = ApiResponse::fromJson($this->getResponseContent($http_response));

        // however thre's no message matching $msg_key, but Lang::get() would return
        // the key if no string exists, which is sufficient
        $this->assertEquals($msg_key, $api->getMessage());

        $this->assertEquals($http_code, $http_response->getStatusCode());
        $this->assertEquals($api_code, $api->getCode());
    }

    // ---------------------------------------------------------------------------------------------

    /**
     * Checks if processException() would properly handle the case when there's no `msg_key` specified in
     * exception handler config for this particular exception type, yet method is ordered to ignore message
     * provided by exception and fall back one from config (which in this case means another fallback to
     * built-in settings).
     */
    public function testProcessExceptionWithMsgEnforceWithNoFallbackMsgKey(): void
    {
        $api_code = random_int($this->min_allowed_code, $this->max_allowed_code);
        $http_code = random_int(RB::ERROR_HTTP_CODE_MIN, RB::ERROR_HTTP_CODE_MAX);
        do {
            $fallback_http_code = random_int(RB::ERROR_HTTP_CODE_MIN, RB::ERROR_HTTP_CODE_MAX);
        } while ($fallback_http_code === $http_code);

        $ex_cfg = [
            'api_code'    => $api_code,
            'http_code'   => $http_code,
            'msg_enforce' => true,
        ];

        $ex_msg = Generator::getRandomString('ex');
        $ex = new \RuntimeException($ex_msg);

        /** @var HttpResponse $http_response */
        $http_response = Lockpick::call(ExceptionHandlerHelper::class,
            'processException', [
                $ex,
                $ex_cfg,
                $fallback_http_code,
            ]);

        $api = ApiResponse::fromJson($this->getResponseContent($http_response));

        $msg = $ex->getMessage();
        $placeholders = [
            'api_code' => $api_code,
            'message'  => ($msg !== '') ? $msg : '???',
        ];
        $expected_msg_key = Lockpick::call(ExceptionHandlerHelper::class,
            'getErrorMessageForException', [
                $ex,
                $http_code,
                $placeholders,
            ]);
        /** @var string $expected_msg_key */
        $expected_msg = \Lang::get($expected_msg_key, $placeholders);

        $this->assertEquals($expected_msg, $api->getMessage());
        $this->assertEquals($http_code, $http_response->getStatusCode());
        $this->assertEquals($api_code, $api->getCode());
    }

    // ---------------------------------------------------------------------------------------------

    /**
     * Performs tests to ensure error() fallback mechanism for HTTP codes works correctly.
     *
     * @param int           $expected_http_code Expected HTTP code to be returned in response.
     * @param HttpException $ex                 Exception to use for testing.
     * @param int           $config_http_code   HTTP code to set as part for exception handler configuration
     */
    protected function doTestErrorMethodFallbackMechanism(int           $expected_http_code,
                                                          HttpException $ex,
                                                          int           $config_http_code): void
    {
        // HAVING incorrectly configured exception handler
        $cfg = [
            'map' => [
                HttpException::class => [
                    HttpResponse::HTTP_NOT_FOUND => [
                        // i.e. OK (0) is invalid code for error response.
                        'api_code'  => BaseApiCodes::EX_HTTP_NOT_FOUND(),
                        'http_code' => $config_http_code,
                    ],
                ],
            ],
        ];
        Config::set(RB::CONF_KEY_EXCEPTION_HANDLER, $cfg);

        /** @var HttpResponse $http_response */
        $http_response = Lockpick::call(ExceptionHandlerHelper::class,
            'error', [
                $ex,
                BaseApiCodes::EX_HTTP_NOT_FOUND(),
                $config_http_code,
                '',
            ]
        );

        // Get parsed response.
        // NOTE: even if $api is not used, calling fromJson() here ensures validity of
        // the response JSON structure.
        $api = ApiResponse::fromJson($this->getResponseContent($http_response));

        // Ensure returned response used HTTP code from the exception
        $this->assertEquals($expected_http_code, $http_response->getStatusCode());
    }

    /**
     * Returns content of localization file for 'default' language.
     *
     * @return array<string, mixed>
     */
    protected function getTranslationForDefaultLang(): array
    {
        // get the translation array for default language
        $default_lang = 'en';
        \App::setLocale($default_lang);

        // We must NOT call langGet() wrapper as we want whole translation array
        /** @var array<string, mixed> $translation */
        $translation = \Lang::get('response-builder::builder');
        return $translation;
    }

    /**
     * Returns ExceptionHandler's configuration array.
     *
     * @return array<string, mixed>
     */
    protected function getExceptionHandlerConfig(): array
    {
        /** @noinspection ArgumentEqualsDefaultValueInspection */
        $cfg = Lockpick::call(ExceptionHandlerHelper::class,
            'getExceptionHandlerConfig', []);
        $this->assertIsArray($cfg);
        $this->assertNotEmpty($cfg);

        /** @var array<string, mixed> $cfg */
        return $cfg;
    }

    /**
     * Checks if exception handler's configuration array structure fullfils expectations.
     *
     * @param array<string, mixed> $params
     * @param int|null $code
     * @param bool     $is_default_handler
     */
    protected function checkExHandlerConfigStructure(array $params, ?int $code = null,
                                                     bool  $is_default_handler = false): void
    {
        if (\is_int($code)) {
            $this->assertGreaterThanOrEqual(RB::ERROR_HTTP_CODE_MIN, $code);
            $this->assertLessThanOrEqual(RB::ERROR_HTTP_CODE_MAX, $code);
        }

        if ($is_default_handler) {
            $mandatory_keys = [
                'api_code',
                'http_code',
            ];
            $optional_keys = [
                'pri',
                'msg_key',
                'msg_force',
            ];
        } else {
            $mandatory_keys = [
                'api_code',
            ];
            $optional_keys = [
                'http_code',
                'pri',
                'msg_key',
                'msg_force',
            ];
        }

        ExtraAsserts::assertArrayHasKeys($mandatory_keys, $params);

        ExtraAsserts::assertIsInteger($params['api_code']);
        $this->assertGreaterThanOrEqual(BaseApiCodes::getMinCode(), $params['api_code']);
        $this->assertLessThanOrEqual(BaseApiCodes::getMaxCode(), $params['api_code']);

        if (\array_key_exists('http_code', $params)) {
            ExtraAsserts::assertIsInteger($params['http_code']);
            $this->assertGreaterThanOrEqual(RB::ERROR_HTTP_CODE_MIN, $params['http_code']);
            $this->assertLessThanOrEqual(RB::ERROR_HTTP_CODE_MAX, $params['http_code']);
        }

        // check config does not contain any unknown keys
        $diff = [];
        $allowed_keys = \array_merge($mandatory_keys, $optional_keys);
        foreach ($params as $key => $val) {
            if (!\in_array($key, $allowed_keys)) {
                $diff[] = $key;
            }
        }

        $sep = "\n  ";
        $code_name = $code ?? '"default"';
        $msg = "Unsupported keys in config for HTTP Exception, handler for code {$code_name}:${sep}" . implode($sep, $diff);
        $this->assertEmpty($diff, $msg);
    }

} // end of class
